Flutter 使用 beamer 实现复杂路由跳转
最近,我准备使用 Flutter Web 搭建一个内部平台,路由场景比较复杂。之前偶然了解到 beamer 这个路由框架比较强大,因此打算一试。
在 Flutter 社区中有许多强大的路由框架,为何选择 beamer 而不选其它更知名库呢?坦白说,我对其它库了解不多。选择 beamer 只是我恰好看到它,且恰好能满足我的需求。这个问题,只能等未来对其它库也了解后,才能给出客观的回答。
我将这次入门的 Demo 开源出来,供大家交流参考:maxiee/maxiee_flutter_beamer_demo
Hello world
一个最简单的 Hello world 如下:
class MyApp extends StatelessWidget {
final _routerDelegate = BeamerDelegate(
locationBuilder: RoutesLocationBuilder(routes: {
'/': (context, state, data) => const HomePage(),
}));
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: BeamerParser(),
routerDelegate: _routerDelegate,
);
}
}
其中:
- build 中传入 BeamerParser 和 BeamerDelegate,由 beamer 接管路由。
- 在
'/'
路由中声明首页。
整体流程
引用官放文档的整体流程图:
在 App 中,通过 URL 来指引页面。
一个路径,通过 BramerParser 解析后,映射到 BeamLocation。BeamLocation 是 beamer 中的核心概念,表示一个或多个页面堆栈的状态。
通过 BeamLocation 构建出目标 Page,并通过 Navigator 完成最终跳转。
页面跳转 & URL 传参
跳转
// Basic beaming
Beamer.of(context).beamToNamed('/books/2');
// Beaming with an extension method on BuildContext
context.beamToNamed('/books/2');
// Beaming with additional data that persist
// throughout navigation within the same BeamLocation
context.beamToNamed('/book/2', data: MyObject());
其中:
- 支持通过 url 路径传递参数
- 也支持通过 data 属性传参
参数解析
解析方法:
final routerDelegate = BeamerDelegate(
locationBuilder: RoutesLocationBuilder(
routes: {
// ...
'/books/:bookId': (context, state, data) {
// Take the path parameter of interest from BeamState
final bookId = state.pathParameters['bookId']!;
// Collect arbitrary data that persists throughout navigation
final info = (data as MyObject).info;
// Use BeamPage to define custom behavior
return BeamPage(
key: ValueKey('book-$bookId'),
title: 'A Book #$bookId',
popToNamed: '/',
type: BeamPageType.scaleTransition,
child: BookDetailsScreen(bookId, info),
);
}
},
),
);
其中:BeamPage 用于对路由过程进行高级声明,比如 Key、返回页面、过场等。
也可以在页面内解析页面入参:
@override
Widget build(BuildContext context) {
final beamState = Beamer.of(context).currentBeamLocation.state as BeamState;
final bookId = beamState.pathParameters['bookId'];
...
}
返回
App 页面返回逻辑不止一种,beamer 支持两种返回模式:
- 向上返回(从堆栈中弹出页面)
- 逆时针返回(返回到前一个状态)
向上导航是指导航回当前页面堆栈中的前一个页面。这通常被称为“弹出”(pop),通过 Navigator 的 pop/maybePop 方法实现。如果没有指定其他操作,默认的应用栏(AppBar)的返回按钮将会调用这个方法。代码示例:
Navigator.of(context).maybePop();
逆时针导航是指返回到之前所在的任何页面。在深层链接的情况下(例如,从/authors/3来到/books/2,而不是从/books),这种返回并不同于普通的弹出操作。Beamer库会保持一个导航历史在 beamingHistory 中,因此可以逆向时间顺序地导航回 beamingHistory 中的前一个条目。这被称为“光束回传”(beaming back)。逆时针导航也是浏览器返回按钮所做的操作,尽管它不是通过 beamBack 实现,而是通过其内部机制。
Beamer.of(context).beamBack();
简而言之,向上返回是在当前页面的历史堆栈中向前一个页面返回,常见于应用内页面导航。而逆时针返回是基于整个导航历史的返回,适用于更复杂的场景,如深层链接或浏览器的历史记录。两者在开发中根据需要选择使用。
对于 Android 返回键,需要通过以下代码加以适配:
MaterialApp.router(
...
routerDelegate: beamerDelegate,
backButtonDispatcher: BeamerBackButtonDispatcher(
delegate: beamerDelegate),
)
页面降级拦截
经常地,在页面跳转时,我们希望进行一些拦截操作。比如有的页面仅允许登陆用户访问,如果用户未登录,应自动降级到登陆页面。在 beamer 中,通过 BeamGuad 能够方便地实现降级逻辑。
比如,实现一个对所有页面的检查,如果用户未登录,跳转到登陆页,代码如下:
final routerDelegate = BeamerDelegate(
// 添加 guard 降级逻辑
guards: [
BeamGuard(
pathPatterns: ['/*'],
check: (ctx, location) => //... 判断用户是否登陆
beamToNamed: (_, __) => '/login' // 降级页面的路径
)
],
locationBuilder: RoutesLocationBuilder(
routes: {
// ...
},
),
);
底部导航栏场景
先来一个经典场景练练手:底部导航栏。参考自官方 Example。
我在官方 Example 基础上做了一些修改。不同之处在与:官方的底部导航栏 Example 是一个单独工程,进入 bottom_navigation 目录后直接运行。而我的 Demo 中,bottom_navigation 模块的入口页面,作为整个 App 的二级页面。也就是说,其实我使用了嵌套路由的技巧。
因此,在我的版本中,UI 展示上会出现两个导航栏,这不是 beamer 出 bug 了,而是我们有意为之,有利于我们了解 beamer 的底层原理。
接下来,以我的版本进行介绍。对于同样入门 breamer 的朋友,我建议直接运行官方 Example,更加简单直观。
跳转流程
在我的修改版本中,从首页点击按钮,进入 BottomNavigationPage 的入口页。BottomNavigationPage 包含一个嵌套路由,可以通过底部导航栏进行切换。在 BottomNavigationPage 中点击 Books 列表元素,将跳转到 BookDetailScreen,从两套 AppBar 中,可以直观看出嵌套路由。
跳转流程如下:
总路由声明
总路由声明,与文章开头一样,在路由表中添加 BottomNavigationPage 的 URL:
class MyApp extends StatelessWidget {
final _routerDelegate = BeamerDelegate(
locationBuilder: RoutesLocationBuilder(routes: {
'/': (context, state, data) => const HomePage(),
'/bottom_navigation': (context, state, data) => BottomNavigationPage(),
}));
// ...
BottomNavigationPage
BottomNavigationPage 页面实现如下:
class BottomNavigationPage extends StatelessWidget {
BottomNavigationPage({super.key});
final _beamerKey = GlobalKey<BeamerState>();
final _routerDelegate = BeamerDelegate(
locationBuilder: BeamerLocationBuilder(
beamLocations: [
BooksLocation(),
ArticlesLocation(),
],
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Bottom Navigation'),
),
body: Beamer(
key: _beamerKey,
routerDelegate: _routerDelegate,
),
bottomNavigationBar: BottomNavigationBarWidget(
beamerKey: _beamerKey,
),
);
}
}
在上面代码中,首先创建了一个 Beamer,表示创建了一个嵌套路由。从 Beamer 有自己的 BeamerDelegate 也能够看出,它有自己的 URL 解析规则。
所谓嵌套路由,相当于在页面内内置了一个内部的路由,在页面外界感知不到,只有页面内能感知到。
locationBuilder
的实现与 MyApp 不同,它使用了 BeamLocation 机制,BeamLocation 用于在页面栈内部定义多套独立的页面堆栈结构。
从底部导航栏的截图中,能够直观理解两套堆栈结构的,Books Tab 有自己的列表页和详情页,Articles Tab 有自己的列表页和详情页。两者之间的路由声明通过 BeamLocation 进行解耦,非常直观。
BottomNavigationBarWidget
下面看底部导航栏的实现:
class BottomNavigationBarWidget extends StatefulWidget {
BottomNavigationBarWidget({required this.beamerKey});
final GlobalKey<BeamerState> beamerKey;
@override
_BottomNavigationBarWidgetState createState() =>
_BottomNavigationBarWidgetState();
}
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
late BeamerDelegate _beamerDelegate;
int _currentIndex = 0;
void _setStateListener() => setState(() {});
@override
void initState() {
super.initState();
_beamerDelegate = widget.beamerKey.currentState!.routerDelegate;
_beamerDelegate.addListener(_setStateListener);
}
@override
Widget build(BuildContext context) {
_currentIndex =
_beamerDelegate.currentBeamLocation is BooksLocation ? 0 : 1;
return BottomNavigationBar(
currentIndex: _currentIndex,
items: [
BottomNavigationBarItem(label: 'Books', icon: Icon(Icons.book)),
BottomNavigationBarItem(label: 'Articles', icon: Icon(Icons.article)),
],
onTap: (index) => _beamerDelegate.beamToNamed(
index == 0 ? '/books' : '/articles',
),
);
}
// ...
}
其中:
- beamerKey 是 BottomNavigationPage 传入的
- 进而从 beamerKey 中取得
_beamerDelegate
实现页面跳转。
BooksLocation
以 BooksLocation 为例(ArticlesLocation 实现类似,省略):
class BooksLocation extends BeamLocation<BeamState> {
@override
List<String> get pathPatterns => ['/books/:bookId'];
@override
List<BeamPage> buildPages(BuildContext context, BeamState state) => [
BeamPage(
key: ValueKey('books'),
title: 'Books',
type: BeamPageType.noTransition,
child: BooksScreen(),
),
if (state.pathParameters.containsKey('bookId'))
BeamPage(
key: ValueKey('book-${state.pathParameters['bookId']}'),
title: books.firstWhere((book) =>
book['id'] == state.pathParameters['bookId'])['title'],
child: BookDetailsScreen(
book: books.firstWhere(
(book) => book['id'] == state.pathParameters['bookId']),
),
),
];
}
主要实现两个方法:
- pathPatterns URL 匹配模式
- buildPages:以声明式构造页面
这里的以声明式构造页面需要尤为重视。在传统的命令式路由中,需要通过调用 push、pop 来添加删除页面。而在声明式路由中,指定层级 URL,在回调中一次性生成所有页面的描述。
BookDetailsScreen
以 BookDetailsScreen 为例,对于内部路由来说,实则是从 /books
跳到 /books/1
,这两次过程都会调用 buildPages,前者的 List<BeamPage>
中只有 BooksScreen 一个元素,而后者有 BooksScreen、BookDetailsScreen 这两个元素。
从 BookDetailsScreen 的截图中可以看出,有两个 AppBar:
- 上方的是 BottomNavigationPage 作为全局路由页面的标题栏
- 下方的是 BookDetailsScreen,作为内部路由页面的标题栏
当然,在实际 App 中不会存在同时出现两个标题栏的情况。当真正遇到嵌套路由场景,需要整合 beamer 的强大功能,打造一套流畅的路由体验。
BeamLocation
Beamer 中最重要的构造是 BeamLocation
,它表示一个或多个页面堆栈的状态。
BeamLocation 的 3 各重要作用:
- 知道它可以处理哪些 URI:
pathPatterns
- 知道如何构建页面堆栈:
buildPages
- 保留一个
state
来提供前 2 个之间的链接。即如果使用通过 URL 传参,在buildPages
时通过state
能解析出参数。
从上面的例子中可以看出,BeamLocation
适合于将某一类路由归到一起,将不同模块路由相互隔离,这在开发大型 App 时非常有帮助。
更多案例
在官方 Example 提供了丰富的场景案例,能够覆盖很多复杂场景。
我建议大家在学习时,直接运行官方 Example,内部是一个个单独工程,每个都跑一跑,基本就有概念了。
我写的 maxiee/maxiee_flutter_beamer_demo 反倒搞得奇奇怪怪,不如官方 Example 直观。
不过,通过写 Demo,亲身接入了一番后,我也对 beamer 有了初步的了解。总之,我的目的已经达到了。
TODO
本文在写作上还有很大的提升空间,直接讲解官方 Example 会更好,maxiee/maxiee_flutter_beamer_demo 搞得太奇怪反而不如不提。未来有缘再完善了。
网络资源
- Introduction - beamer.dev
- beamer | Flutter Package
- maxiee/maxiee_flutter_beamer_demo
- Router class - widgets library - Dart API
本文作者:Maeiee
本文链接:Flutter 使用 beamer 实现复杂路由跳转
版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!
喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!